站在巨人的肩膀上,能讓我們走得更遠,不管是引用別人寫好的程式碼,或是在多個檔案間相互引用自己寫好的程式碼,我們都得來好好瞭解一下 JS 中的模組的演進歷史與運作。
JS 中的模組 Module
概念,指的就是將程式碼片段視為可獨立執行的一部份,透過這種方式組合程式碼,讓程式碼更易於維護、提高可重用性、易讀性等等。
可以想像,時至今日 JS 已被用於一些大型專案或系統中,如果只是一個單獨的檔案,光是變數名稱衝突、執行順序相依等等問題就能搞的開發者焦頭爛額。
也因此,模組化的概念自然而然被導入。
早期,儘管 JS 大多情況相對沒有那麼大規模的運用,但仍有可能希望拆分模組的需求。
在那個時候,由於 var
本身的作用域是函式作用域,最開始是使用 IIFE 加上物件概念的方式來避免全域污染。
var CustomModule = (function() {
// 存於模組內的變數和函式
var privateVar = 'bar';
function privateMethod() {
return privateVar;
}
return {
foo: privateMethod
};
})();
console.log(CustomModule.foo()); // "bar"
console.log(typeof privateVar);
這種方式能夠封閉變數於 IIFE 中,透過閉包的概念,僅提供外部固定可操作的方式,其他內部的變數與函式也不會污染外部空間。
其他語言有的命名空間(namespace)概念,也可以透過多層物件包裹的方式來實現,雖然相對粗糙,但確實是那時候的解法。
其中一個缺點是假設一個方法是在一個長串聯的命名空間尾端,則每次要呼叫的時候都要記得那麼長的命名空間鏈,是相當不方便的。
下一個階段算是 2009 年的時候,隨著 Node.js 的推出與發展,被提出用於伺服器端的模組運用標準 Common.js(瀏覽器不支援)。
過往瀏覽器載入多個 JS 檔案時往往使用 <script>
標籤來載入,但後端不像前端這樣有 html
文件和腳本標籤能使用,那該怎麼引入其他 JS 的依賴呢?這就有了 Common.js。
最開始叫做 Server.js,因為本來主要目標為伺服器程式開發,後來希望展示與推廣他的泛用性才改名為 Common.js。
實際上並沒有一個函式庫叫 Common.js,如上所說,他是一個規範,而 Node.js 於其程式中便依該規範實作了模組的引入與導出。
//這個例子於瀏覽器環境不會跑,要測試請使用 node 環境
//第一個檔案:CustomModule.js,撰寫模組內容
var CustomModule = {
foo: function() {
return 'bar';
}
};
module.exports = CustomModule;
// 第二個檔案,引入模組並使用
var CustomModule = require('./CustomModule');
console.log(CustomModule.foo()); // "bar"
透過 export
關鍵字來決定檔案要匯出的內容,通過 require
來引入檔案為一個物件,引入時也可以直接針對引入物件的函式以同樣名稱進行引入。
特色是對模組的同步引入,必須引入完成後才會往下執行,適用於伺服器端的開發流程,且明確以 require
申明引入對象,能夠較好理解與管理依賴。
(註:Node.js 於 2013 年 5 月由 npm (Node.js 的模組管理器)的作者宣佈廢棄其中 Common.js 的使用)
特別提一下由於基於當時 Common.js 規範開發的 Node.js 的模組是無法直接被瀏覽器使用的(引入需要 require
語法,但瀏覽器並不支援),但又有很多好用的函式庫在上面,所以對應的專案 Browserify 應運而生。
本質上 Browserify
是一個模組的打包工具,將本來僅能用於後端的模組,打包生成為一個瀏覽器端可以使用的模組。
打包時必須使用 npm
環境,因為 Browserify
本身也是一個透過 npm
安裝的模組,對模組進行打包後會生成一個瀏覽器可用的檔案,步驟如下:
但有些僅用於後端的模組仍無法透過這個方式被瀏覽器使用,因本質上 Node.js 環境和瀏覽器環境就是有所不同,使用時仍須清楚該模組的作用範圍與限制。
基於 Common.js
的規範,為了讓 Common.js
規範能更廣泛地被使用,後來對模組該如何被使用出現了分歧,其中一派就是 AMD(Asynchronous Module Definition)。
如其名中的「異步」,主要是用於瀏覽器的解決方案。
因為瀏覽器中如果使用同步載入,會造成其他操作的阻塞,所以發展出這個方式來異步引入需要的模組。
最知名的實踐是 require.js。
require.js 的使用需要載入 reuqire.js 本身的函式庫,如透過 script tag 載入如下連結
https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js
然後便可以使用這樣的語法
//檔案 1:定義 module A,提供 greet 方法
define('moduleA', [], function() {
return {
greet: function() {
return 'Hello from Module A';
}
};
});
//檔案 2:定義 module B,,引入 module A,提供 greet 方法
define('moduleB', ['moduleA'], function(moduleA) {
return {
greet: function() {
return moduleA.greet() + ' and Hello from Module B';
}
};
});
//檔案 3:引入 module B,呼叫 B 的 greet 方法
require(['moduleB'], function(moduleB) {
console.log(moduleB.greet());
//"Hello from Module A and Hello from Module B"
});
其中 define
用於宣告一個模組, return
中寫明對外公開的用法,require
則用於引入被 define
宣告的模組。
require
和 define
後面的函式執行會等待前面定義的[]
模組陣列模組皆載入完畢後再執行。
引入模組的部分,有另一個語法叫做 require.config
,能聲明需要使用的模組相對路徑,用起來會像這樣:
require.config({
paths: {
'libName': 'libSource Url / Path'
},
shim: {
'libName': {
exports: 'the function/variable you like to export'
},
}
});
require(['libName'], function(libName) {
//Then you can call libName. things you exported
});
透過這樣的宣告,便能直接在 define
時直接指名 libName
,他會辨識到是連結到後面的路徑/網址,另外如果 libName
並不是一個 AMD 模組,而是被預期為同步載入的對象,當我們要以 AMD 的方式載入,需要額外加上 shim
語法,來指名針對該載入對象我們關注的引入對象($
)。
通常 AMD 模組會使用 define
來聲明依賴關係,所以要判斷時簡單一點可以這樣判斷,如果真的要嚴格檢查,就是對照上面提供的 AMD 標準,如不符合,就不是 AMD 模組。
AMD 模組提供了一個異步載入模組的方式,讓瀏覽器使用模組不會因同步載入被阻塞,require.js
曾是風行一時的瀏覽器模組載入方式。
基於 Common.js 和 AMD 的規範,CMD 規範接著推出。比起「異步」載入,CMD 是使用「延遲」載入,僅在需要時才載入。
CMD 的語法也是使用 define
配上 require
,但用法與 AMD 略有不同。
define(function(require, exports) {
var a = require('a')
if (false) {
var b = require('b')// will not load b
}
})
AMD 會在最開始的時候聲明所有要載入的模組,且定義後立刻進行載入。
CMD 則是在內部使用到 require
時才同步載入,若沒有執行該行 require
,則不會進行載入,這便是所謂「延遲」載入的行為。
使用 CMD 的範例之一便是 sea.js
,一個用於瀏覽器載入模組的載入器。該 repo 的作者有一篇關於發展歷史的文章也很值得一讀,提到他的觀點來看 CJS、AMD、CMD(這個 comment 也值得一看), 與 sea.js 誕生的原因。
同樣被 CJS 與 AMD 啟發,UMD 的誕生是為了提供更好的環境相容性。
這個規範提供一種定義模組的方式,使模組能夠被無論是 CJS 或 AMD 方式載入。
對於一個模組的作者而言,無疑是更加方便的一種做法。
實踐上總歸來說我們的模組會回傳一個要被導出的物件/函式,可是 CJS,AMD,CMD 他們都要求一個不一樣的導出方法,那該怎麼辦呢?
這時候就輪到設計模式中的工廠模式(Factory Pattern)出場。
簡單的說,工廠模式用於創建物件,目的是集中了物件創造方式的邏輯,根據不同的條件回傳不同的物件。
UMD 的宣告就用了這種模式。
((global, factory) => {
if (typeof define === "function" && define.amd) {
// AMD
define(["yourModule"], factory);
} else if (typeof exports === "object") {
// CommonJS
module.exports = factory(require("yourModule"));
} else {
// Browser global
root.yourModule = factory(global.yourModule);
}
})(this, (yourModule) => {
// Module implementation
return {
};
});
可以看到上面的方式對前述的各種模組標準都有對應偵測方式,並依據偵測到是哪種模組載入環境,來使用對應的導出方式。透過這個方式,這個模組便能夠被各種載入環境使用。
除了相依於環境的特定語法(如瀏覽器或後端特有操作),實際上無論是哪種寫法的模組,背後都依然是 JavaScript,當然,他們是能夠被轉換成相容於其他方式的寫法的。
手動改寫當然是一個辦法,但數量一多可能嫌麻煩,又剛好沒有其他適合的解決方案,網路上其實有一些相互轉換的方式能夠使用,以下提供三種透過 npm 安裝後能使用的方案:
儘管現時點已經是 2024 了,我們有了更好更通用的解決方案:ES 6 所引入的 module
概念,我們會於下篇介紹,但程式的世界永遠不缺 legacy code,那些流傳下來的專案、仍然活著的古董都是我們開發中有可能遇到的對象,甚至我們還需要親自修改。
懂的過往的脈絡,能讓我們在處理這些過往工程時,更得心應手。